//**********************************************************************
//
//<copyright>
//
//BBN Technologies
//10 Moulton Street
//Cambridge, MA 02138
//(617) 873-8000
//
//Copyright (C) BBNT Solutions LLC. All rights reserved.
//
//</copyright>
//**********************************************************************
//
//$Source:
///cvs/darwars/ambush/aar/src/com/bbn/ambush/mission/MissionHandler.java,v
//$
//$RCSfile: MissionHandler.java,v $
//$Revision: 1.10 $
//$Date: 2004/10/21 20:08:31 $
//$Author: dietrick $
//
//**********************************************************************

package com.bbn.openmap.dataAccess.mapTile;

import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsEnvironment;
import java.awt.Paint;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import java.util.logging.Level;

import com.bbn.openmap.Environment;
import com.bbn.openmap.Layer;
import com.bbn.openmap.image.ImageFormatter;
import com.bbn.openmap.image.ImageServer;
import com.bbn.openmap.image.PNG32ImageFormatter;
import com.bbn.openmap.image.SunJPEGFormatter;
import com.bbn.openmap.layer.imageTile.MapTileLayer;
import com.bbn.openmap.layer.shape.ShapeLayer;
import com.bbn.openmap.omGraphics.OMColor;
import com.bbn.openmap.proj.Mercator;
import com.bbn.openmap.proj.Proj;
import com.bbn.openmap.proj.Projection;
import com.bbn.openmap.proj.coords.LatLonPoint;
import com.bbn.openmap.util.ComponentFactory;
import com.bbn.openmap.util.Debug;
import com.bbn.openmap.util.PropUtils;

/**
 * The MapTileMaker is an ImageServer extension that knows how to create image
 * tile sets, like the kind of tiles used by Google Maps and OpenStreetMap, Tile
 * Map Service (TMS). It uses ZoomLayerMarker objects to define how tiles are
 * created for different zoom levels. You can run this class as an application.
 * With the -create option, it will create a sample properties file to
 * demonstrate what properties are needed to run it.
 * <p>
 * 
 * The properties look much like the ImageServer properties, with a couple of
 * additional values:
 * <p>
 * 
 * <pre>
 * ### MapTileMaker/ImageServer properties ###
 * antialiasing=false
 * # Image formatter definition
 * formatters=formatter1
 * # Layer definitions for layers that are available for zoom levels
 * layers=layer1 layer2 ...
 * rootDir=Path to top level directory for tiles
 * zoomLevels=zoom1 zoom2
 * 
 * formatter1=.class=com.bbn.openmap.image.PNGImageIOFormatter
 * layer1.class=com.bbn.openmap.layer.shape.ShapeLayer
 * # ... layer1 properties follow, see layer docs for specific properties for that layer
 * 
 * # Then, for each zoom level
 * zoom1.class=com.bbn.openmap.image.ZoomLevelInfo
 * #Optional, to limit tile areas created, in sets of 4, must be in lat,lon order
 * zoom1.bounds=lat lon lat lon
 * zoom1.description=Tiles for zoom level 4
 * #Marker names for layers to be rendered, the property prefixes for the layers held by TileMaker
 * zoom1.layers=layer1 layer2
 * zoom1.name=ZoomLayerInfo 4
 * zoom1.zoomLevel=4
 * # If defined, copies of zoomLevel tiles will be scaled through range level.
 * zoom1.range=0
 * 
 * # and repeat for every zoomLevel defined
 * </pre>
 * 
 * @author dietrick
 */
public class MapTileMaker extends ImageServer implements EmptyTileHandler {

    public final static String ROOT_DIRECTORY_PROPERTY = "rootDir";
    public final static String ZOOM_LEVELS_PROPERTY = "zoomLevels";

    protected String rootDir;
    protected List<ZoomLevelMaker> zoomLevels;
    protected MapTileCoordinateTransform mtcTransform = new OSMMapTileCoordinateTransform();
    protected int TILE_SIZE = mtcTransform.getTileSize();

    /**
     * Empty constructor that expects to be configured later.
     */
    public MapTileMaker() {
    }

    /**
     * To create the TileMaker, you hand it a set of properties that let it
     * create an array of layers, and also to set the properties for those
     * layers. The properties file for the ImageServer looks strikingly similar
     * to the openmap.properties file. So, all the layers get set up here...
     */
    public MapTileMaker(Properties props) {
        super(props);
    }

    /**
     * Same as the other constructor, except that the properties can have a
     * prefix in front of them. The format of the prefix has to match how the
     * property is specified the the properties file, which may include the
     * period - i.e server1.imageServer.layers, the server1. is the prefix that
     * should get passed in. The ImageMaster does this.
     */
    public MapTileMaker(String prefix, Properties props) {
        super(prefix, props, null);
    }

    /**
     * Create an TileMaker that should be configured with a Properties file. The
     * prefix given is to scope the ImageServer properties to this instance. The
     * Hashtable is for reusing any layers that may already be instantiated.
     */
    public MapTileMaker(String prefix, Properties props, Map<String, Layer> instantiatedLayers) {
        super(prefix, props, instantiatedLayers);
    }

    /**
     * Create an TileMaker from an array of Layers and an ImageFormatter. It's
     * assumed that the layers are already configured.
     * 
     * @param layers the array of layers.
     * @param formatter the ImageFormatter to use for the output image format.
     */
    public MapTileMaker(Layer[] layers, ImageFormatter formatter) {
        super(layers, formatter);
    }

    @SuppressWarnings("unchecked")
    public void setProperties(String prefix, Properties props) {
        super.setProperties(prefix, props);
        prefix = PropUtils.getScopedPropertyPrefix(prefix);

        rootDir = props.getProperty(prefix + ROOT_DIRECTORY_PROPERTY, rootDir);
        List<ZoomLevelMaker> zoomLevels = (List<ZoomLevelMaker>) PropUtils.objectsFromProperties(props, prefix
                + ZOOM_LEVELS_PROPERTY, ComponentFactory.ClassNameProperty);
        getZoomLevels().addAll(zoomLevels);
    }

    public Properties getProperties(Properties props) {
        props = super.getProperties(props);
        String prefix = PropUtils.getScopedPropertyPrefix(this);
        props.put(prefix + ROOT_DIRECTORY_PROPERTY, PropUtils.unnull(rootDir));

        StringBuffer buf = new StringBuffer();
        for (ZoomLevelMaker zfi : getZoomLevels()) {
            buf.append(zfi.getPropertyPrefix()).append(" ");
            zfi.getProperties(props);
        }

        if (buf.length() > 0) {
            props.put(prefix + ZOOM_LEVELS_PROPERTY, buf.toString().trim());
        }

        return props;
    }

    public Properties getPropertyInfo(Properties props) {
        props = super.getPropertyInfo(props);
        PropUtils.setI18NPropertyInfo(Environment.getI18n(), props, com.bbn.openmap.dataAccess.mapTile.MapTileMaker.class, ROOT_DIRECTORY_PROPERTY, "Tile Directory", "Root directory for holding tile files.", "com.bbn.openmap.util.propertyEditor.DirectoryPropertyEditor");
        return props;
    }

    /**
     * Creating the tile using the ImageServer methodology, knowing that the
     * MapTileMaker has been configured with an openmap.properties.file and
     * knows about layers and their marker names.
     * 
     * @param uvx uv x pixel coordinate
     * @param uvy uv y pixel coordinate
     * @param zoomInfo zoom level for image tile
     * @param proj projection for tile
     * @return byte[] for raw image bytes
     */
    public byte[] makeTile(double uvx, double uvy, ZoomLevelMaker zoomInfo, Proj proj) {
        Point2D center = tileUVToLatLon(new Point2D.Double(uvx + .5, uvy + .5), zoomInfo.getZoomLevel());
        proj.setScale(mtcTransform.getScaleForZoom(zoomInfo.getZoomLevel()));
        proj.setCenter(center);
        proj.setHeight(TILE_SIZE);
        proj.setWidth(TILE_SIZE);

        return createImage(proj, -1, -1, zoomInfo.getLayers());
    }

    /**
     * Creating a tile more freely, when you have a set of layers you want to
     * draw into the tile.
     * 
     * @param uvx uv x pixel coordinate
     * @param uvy uv y pixel coordinate
     * @param zoomLevel zoom level for tile
     * @param layers layers to include in image
     * @param proj projection for tile
     * @param background the paint to use for the background of the image.
     * @return byte[] for raw image bytes
     */
    public byte[] makeTile(double uvx, double uvy, int zoomLevel, List<Layer> layers, Proj proj,
                           Paint background) {
        Point2D center = tileUVToLatLon(new Point2D.Double(uvx + .5, uvy + .5), zoomLevel);
        proj.setScale(mtcTransform.getScaleForZoom(zoomLevel));
        proj.setCenter(center);
        proj.setHeight(TILE_SIZE);
        proj.setWidth(TILE_SIZE);

        return createImageFromLayers(proj, -1, -1, layers, background);
    }

    /**
     * The main call to make for a tile to be created. This method will cause
     * the correct mapTile method to be called, depending on the configuration
     * of the ZoomLevelMakers.
     * 
     * @param uvx
     * @param uvy
     * @param zoomInfo
     * @param proj
     * @return the final file path used, with any extensions added.
     * @throws IOException
     */
    public String makeTileFile(double uvx, double uvy, ZoomLevelMaker zoomInfo, Proj proj)
            throws IOException {

        byte[] imageBytes = zoomInfo.makeTile(uvx, uvy, this, proj);

        String filePath = zoomInfo.formatImageFilePath(getRootDir(), (int) uvx, (int) uvy);
        return writeImageFile(imageBytes, filePath, true);
    }

    /**
     * EmptyTileHandler method, called when a MapTileFactory needs to create and
     * return a missing tile.
     * 
     * @param imagePath the path of the missing tile that is going to be used as
     *        cache lookup later.
     * @param x the uv x coordinate of the tile.
     * @param y the uv y coordinate of the tile.
     * @param zoomLevel the zoom level of the tile.
     * @param mtcTransform the transform that converts x,y coordinates to
     *        lat/lon and describes the layout of the uv tile coordinates.
     * @param proj the map projection, in case that matters what should be
     *        returned for the empty tile.
     * @return BufferedImage for image tile, or null if there's a problem
     */
    public BufferedImage getImageForEmptyTile(String imagePath, int x, int y, int zoomLevel,
                                              MapTileCoordinateTransform mtcTransform,
                                              Projection proj) {

        Layer[] layers = null;

        List<ZoomLevelMaker> zoomLevels = getZoomLevels();
        if (zoomLevels != null && !zoomLevels.isEmpty()) {
            for (ZoomLevelMaker zoomLevelMaker : zoomLevels) {
                if (zoomLevelMaker.getZoomLevel() == zoomLevel) {
                    List<Layer> layerList = zoomLevelMaker.getLayerList();
                    if (layerList != null) {
                        layers = layerList.toArray(new Layer[layerList.size()]);
                    } else {
                        // The zoom level was defined, but layers for the zoom
                        // level weren't, use top-level layers.
                        layers = getLayers();
                    }
                    break;
                }
            }
        } else {
            // particular layer configurations for zoom levels aren't defined,
            // use all layers.
            layers = getLayers();
        }

        // Either layers weren't defined at all, or a particular zoom level
        // wasn't defined.
        if (layers == null) {
            if (logger.isLoggable(Level.FINE)) {
                logger.fine("There are no layers defined for zoom level" + zoomLevel
                        + " in MapTileMaker, can't create image");
            }
            return null;
        }

        LatLonPoint center = tileUVToLatLon(new Point2D.Double(x + .5, y + .5), zoomLevel);
        Mercator m = new Mercator(center, mtcTransform.getScaleForZoom(zoomLevel), TILE_SIZE, TILE_SIZE);

        BufferedImage bufferedImage = new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB);

        GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
        Graphics graphics = ge.createGraphics(bufferedImage);
        graphics.setClip(0, 0, TILE_SIZE, TILE_SIZE);

        if (graphics == null) {
            return null;
        }

        m.drawBackground((Graphics2D) graphics, background);

        if (layers != null) {
            for (int i = layers.length - 1; i >= 0; i--) {
                try {
                    layers[i].renderDataForProjection(proj, graphics);
                } catch (Exception e) {
                    if (logger.isLoggable(Level.FINE)) {
                        logger.fine("Problem rendering layer " + i);
                    }
                }
            }

        } else if (logger.isLoggable(Level.FINE)) {
            logger.fine("no layers available for image");
        }

        graphics.dispose();

        return bufferedImage;
    }

    /**
     * Main call to make a set of tiles. The ZoomLevelMaker objects have to be
     * configured correctly. Each ZoomLevelMaker has to have its zoom levels set
     * (initial and range), the bounds of the areas where tiles are desired, and
     * the layers desired on those tiles. For the layers, the ZoomLevelInfo can
     * have a List of Strings corresponding to the property prefixes of the
     * layers already set on the MapTileMaker, or it can have a List of Layer
     * objects to use. The root output directory has to be set in the
     * MapTileMaker. The image formatter also needs to be set.
     */
    public void makeTiles() {

        if (rootDir != null) {
            File rd = new File(rootDir);
            if (!rd.exists()) {
                rd.mkdir();
            }
        }

        Proj proj = new Mercator(new LatLonPoint.Double(), 10000, MapTileCoordinateTransform.TILE_SIZE, MapTileCoordinateTransform.TILE_SIZE);

        List<ZoomLevelMaker> zoomLevels = getZoomLevels();
        for (ZoomLevelMaker zfi : zoomLevels) {
            logger.info("writing zoom level " + zfi.getName() + " tiles...");
            int zoomLevel = zfi.getZoomLevel();
            for (Rectangle2D bounds : zfi.getUVBounds(mtcTransform, zoomLevel)) {
                if (logger.isLoggable(Level.FINE)) {
                    logger.fine(" creating tiles " + bounds);
                }
                int startx = (int) bounds.getX();
                int starty = (int) bounds.getY();
                int xofflimit = (int) bounds.getWidth();
                int yofflimit = (int) bounds.getHeight();

                for (int xoff = 0; xoff < xofflimit; xoff++) {
                    int x = startx + xoff;

                    // Reset every x loop for first time check through y loop
                    String parentDirPath = null;

                    for (int yoff = 0; yoff < yofflimit; yoff++) {
                        int y = starty + yoff;

                        if (parentDirPath == null) {
                            parentDirPath = zfi.formatParentDirectoryName(getRootDir(), x, y);
                            File parentDir = new File(parentDirPath);
                            if (!parentDir.exists()) {
                                parentDir.mkdirs();
                            }
                        }

                        try {
                            String outputFile = makeTileFile(x, y, zfi, proj);
                            if (logger.isLoggable(Level.FINER)) {
                                logger.finer("wrote: " + outputFile);
                            }
                        } catch (IOException ioe) {
                            logger.warning("Caught IOException writing " + x + ", " + y + ", "
                                    + zfi);
                        }
                    }

                }
            }

            // At this point, for a specific ZoomLevelInfo, the basic tiles for
            // it have been created. Now we can check the range and create tiles
            // for the range out of the new tiles.
            int range = zfi.getRange();
            if (range < zoomLevel) {
                Properties rangeProps = new Properties();
                MapTileLayer tileLayer = new MapTileLayer();
                StandardMapTileFactory tileFactory = new StandardMapTileFactory();
                tileFactory.setRootDir(getRootDir());
                tileFactory.setFileExt(getFormatter().getFormatLabel());
                tileLayer.setTileFactory(tileFactory);

                List<Layer> subLayers = new ArrayList<Layer>();
                subLayers.add(tileLayer);

                for (int rangeZoomLevel = zoomLevel - 1; rangeZoomLevel >= range; rangeZoomLevel--) {
                    tileLayer.setZoomLevel(rangeZoomLevel);
                    ZoomLevelInfo rangeZFI = new ZoomLevelInfo();
                    rangeZFI.setZoomLevel(rangeZoomLevel);
                    rangeZFI.setScale(mtcTransform.getScaleForZoom(rangeZoomLevel));
                    // Create new tiles from the tiles one zoom level up
                    tileLayer.setZoomLevel(rangeZoomLevel + 1);

                    for (Rectangle2D rawBounds : zfi.getBounds()) {
                        Rectangle2D bounds = rangeZFI.getUVBounds(rawBounds, mtcTransform, rangeZoomLevel);
                        if (logger.isLoggable(Level.INFO)) {
                            logger.fine(" creating subtiles " + bounds);
                        }
                        int startx = (int) bounds.getX();
                        int starty = (int) bounds.getY();
                        int xofflimit = (int) bounds.getWidth();
                        int yofflimit = (int) bounds.getHeight();

                        for (int xoff = 0; xoff < xofflimit; xoff++) {
                            int x = startx + xoff;

                            // Reset every x loop for first time check through y
                            // loop
                            String parentDirPath = null;

                            for (int yoff = 0; yoff < yofflimit; yoff++) {
                                int y = starty + yoff;

                                if (parentDirPath == null) {
                                    parentDirPath = rangeZFI.formatParentDirectoryName(getRootDir(), x, y);
                                    File parentDir = new File(parentDirPath);
                                    if (!parentDir.exists()) {
                                        parentDir.mkdirs();
                                    }
                                }

                                try {

                                    byte[] imageBytes = makeTile(x, y, rangeZoomLevel, subLayers, proj, OMColor.clear);

                                    String filePath = rangeZFI.formatImageFilePath(getRootDir(), (int) x, (int) y);
                                    String outputFile = writeImageFile(imageBytes, filePath, true);

                                    if (logger.isLoggable(Level.INFO)) {
                                        logger.finer("wrote: " + outputFile);
                                    }
                                } catch (IOException ioe) {
                                    logger.warning("Caught IOException writing " + x + ", " + y
                                            + ", " + zfi);
                                }
                            }

                        }

                    }
                }

            }
        }

        logger.info("done writing tiles");
    }

    public String getRootDir() {
        return rootDir;
    }

    public void setRootDir(String rootDir) {
        this.rootDir = rootDir;
    }

    public List<ZoomLevelMaker> getZoomLevels() {
        if (zoomLevels == null) {
            zoomLevels = new LinkedList<ZoomLevelMaker>();
        }
        return zoomLevels;
    }

    public void setZoomLevels(List<ZoomLevelMaker> zoomLevels) {
        this.zoomLevels = zoomLevels;
    }

    public void createDefaultZoomLevels(int maxZoomLevel) {
        Layer[] layers = getLayers();
        List<ZoomLevelMaker> zoomLevels = getZoomLevels();
        List<String> layerNames = new LinkedList<String>();
        for (int i = 0; i < layers.length; i++) {
            String layerName = layers[i].getPropertyPrefix();
            if (layerName != null) {
                layerNames.add(layerName);
            } else {
                logger.info("no name for layer[" + i + "]");
            }
        }

        zoomLevels.clear();

        for (int i = 0; i <= maxZoomLevel; i++) {
            ZoomLevelMaker zfi = new ZoomLevelMaker("ZoomLayerInfo " + i, "Tiles for zoom level "
                    + i, i);
            zfi.setLayers(layerNames);
            zfi.setPropertyPrefix("zoom" + i);
            zoomLevels.add(zfi);
        }
    }

    /**
     * @param latlon a Point2D whose x component is the longitude and y
     *        component is the latitude
     * @param zoom Tile Map Service (TMS) style zoom level (0-19 usually)
     * @return The "tile number" whose x and y components each are floating
     *         point numbers that represent the distance in number of tiles from
     *         the origin of the whole map at this zoom level. At zoom=0, the
     *         lat,lon point of 0,0 maps to 0.5,0.5 since there is only one tile
     *         at zoom level 0.
     */
    public Point2D latLonToTileUV(Point2D latlon, int zoom) {
        return mtcTransform.latLonToTileUV(latlon, zoom, null);
    }

    public Point2D latLonToTileUV(Point2D latlon, int zoom, Point2D ret) {
        return mtcTransform.latLonToTileUV(latlon, zoom, ret);
    }

    /**
     * @param tileUV a Point2D whose x,y coordinates represent the distance in
     *        number of tiles (each 256x256) from the origin (where the origin
     *        is 90lat,-180lon)
     * @param zoom Tile Map Service (TMS) style zoom level (0-19 usually)
     * @return a LatLonPoint whose x coordinate is the longitude and y
     *         coordinate is the latitude, decimal degrees.
     */
    public LatLonPoint tileUVToLatLon(Point2D tileUV, int zoom) {
        return mtcTransform.tileUVToLatLon(tileUV, zoom, null);
    }

    public LatLonPoint tileUVToLatLon(Point2D tileUV, int zoom, LatLonPoint ret) {
        return mtcTransform.tileUVToLatLon(tileUV, zoom, ret);
    }

    public static void main(String[] args) {

        com.bbn.openmap.util.ArgParser ap = new com.bbn.openmap.util.ArgParser("MapTileMaker");

        ap.add("properties", "The properties file to use for image tiles.", 1);
        ap.add("create", "Create a sample properties file at a path", 1);

        if (!ap.parse(args)) {
            ap.printUsage();
            System.exit(0);
        }

        String arg[];

        Properties props = null;
        arg = ap.getArgValues("properties");
        if (arg != null) {
            String ps = arg[0];
            try {

                URL url = PropUtils.getResourceOrFileOrURL(null, ps);
                InputStream inputStream = url.openStream();

                props = new Properties();
                props.load(inputStream);

                MapTileMaker tim = new MapTileMaker(props);
                tim.makeTiles();

            } catch (MalformedURLException murle) {
                Debug.error("TileMaker can't find properties file: " + arg[0]);
            } catch (IOException ioe) {
                Debug.error("TileMaker can't create images: IOException");
            }
        }

        arg = ap.getArgValues("create");
        if (arg != null) {
            String outputFile = arg[0];
            MapTileMaker tim;
            if (props == null) {
                ShapeLayer shapeLayer = new ShapeLayer();
                props = new Properties();
                props.put("shape.prettyName", "Countries");
                props.put("shape.shapeFile", "data/shape/world/cntry02/cntry02.shp");
                props.put("shape.fillColor", "FFBBBBBB");
                shapeLayer.setProperties("shape", props);

                tim = new MapTileMaker(new Layer[] { shapeLayer }, new SunJPEGFormatter());
                tim.createDefaultZoomLevels(4);

                tim.setRootDir("<Path to top level directory for tiles>");
                tim.setFormatter(new PNG32ImageFormatter());

            } else {
                tim = new MapTileMaker(props);
            }

            Properties configurationProps = new Properties();
            configurationProps = tim.getProperties(configurationProps);

            StringBuilder sb = new StringBuilder("#### MapTileMaker Properties ####\n");

            if (!configurationProps.isEmpty()) {
                TreeMap orderedProperties = new TreeMap(configurationProps);
                for (Iterator keys = orderedProperties.keySet().iterator(); keys.hasNext();) {
                    String key = (String) keys.next();
                    String value = configurationProps.getProperty(key);

                    if (value != null) {
                        sb.append(key).append("=").append(value).append("\n");
                    }
                }
            }

            try {
                FileOutputStream fos = new FileOutputStream(outputFile);
                PrintStream ps = new PrintStream(fos);
                ps.println(sb.toString());
                ps.close();
            } catch (IOException ioe) {
                logger.warning("caught IOException writing property file: " + ioe.getMessage());
            }

        }

        System.exit(0);
    }

}
